/**
 * Copyright © 2008, EMC Corporation.
 * Redistribution and use in source and binary forms, with or without modification, 
 * are permitted provided that the following conditions are met:
 *
 *     + Redistributions of source code must retain the above copyright notice, 
 *       this list of conditions and the following disclaimer.
 *     + Redistributions in binary form must reproduce the above copyright 
 *       notice, this list of conditions and the following disclaimer in the 
 *       documentation and/or other materials provided with the distribution.
 *     + The name of EMC Corporation may not be used to endorse or promote 
 *       products derived from this software without specific prior written 
 *       permission.
 *
 *      THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 
 *      "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 
 *      TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 
 *      PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS 
 *      BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 
 *      CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 
 *      SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 
 *      INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 *      CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 
 *      ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 
 *      POSSIBILITY OF SUCH DAMAGE.
 */
package com.emc.esu.api.rest {
	import com.adobe.net.URI;
	import com.emc.esu.api.EsuApi;
	import com.emc.esu.api.EsuCallbackState;
	import com.emc.esu.api.EsuError;
	import com.hurlant.crypto.Crypto;
	import com.hurlant.crypto.hash.HMAC;
	
	import flash.events.Event;
	import flash.events.HTTPStatusEvent;
	import flash.events.IOErrorEvent;
	import flash.events.SecurityErrorEvent;
	import flash.net.URLLoaderDataFormat;
	import flash.net.URLRequestHeader;
	import flash.utils.ByteArray;
	import flash.utils.Dictionary;
	
	import mx.formatters.DateFormatter;
	import mx.logging.ILogger;
	import mx.logging.Log;
	import mx.utils.ArrayUtil;
	import mx.utils.Base64Encoder;
	
	/**
	 * Handles REST requests to the ESU server.  Computes the signature,
	 * creates the URLLoader and handles the asynchronous responses.
	 */
	public class EsuRestRequest {
		private static const DATE_FORMAT:String = "EEE, DD MMM YYYY JJ:NN:SS";
		
		// Keeps track of running requests and ensures they don't get garbage collected.
		private static var runningRequests:Array = new Array();
		
		private var resource:String;
		private var uri:URI;
		private var headers:Dictionary;
		private var responseProcessor:Function;
		private var callback:Function;
		private var userState:Object;
		private var loader:RestLoader;
		private var responseHeaders:Object;
		private var requestHeaders:Array;
		private var requestData:ByteArray;
		private var responseData:ByteArray;
		private var method:String;
		private var secret:ByteArray;
		private var esu:EsuApi;
		private var connectTimeout:int;
		private var rawHeaders:String;
		private var complete:Boolean = false;
		private var privateState:Object;
		
	    private static var logger:ILogger = Log.getLogger("EsuApi");
	    
		/**
		 * Creates a new EsuRestRequest
		 * @param resource the resource being accessed
		 * @param uri the full URI to the resource
		 */
		public function EsuRestRequest( resource:String, uri:URI, connectTimeout:int ) {
			this.resource = resource;
			this.uri = uri;
			this.headers = new Dictionary();
			this.loader = new RestLoader();

			this.loader.dataFormat = URLLoaderDataFormat.BINARY;
			this.connectTimeout = connectTimeout;
			this.loader.requestTimeout = connectTimeout/1000.0;
		}
		
		/**
		 * Adds a header to the request
		 * @param name the header name (e.g. x-emc-uid)
		 * @param value the header's value
		 */
		public function addHeader( name:String, value:String ):void {
			headers[name]=value;
		}
		
		/**
		 * Sets the data that will be sent as the body of the request.
		 * @param data the request data
		 */
		public function setRequestData( data:ByteArray ):void {
			requestData = data;
		}
		
		/**
		 * Sets the callback function.  This function will be invoked when
		 * the request and response have completed.  This function should
		 * have the signature 
		 * <code>function( state:EsuCallbackState ):void</code>
		 * @param cb The callback function
		 */
		public function setCallback( cb:Function ):void {
			callback = cb;
		}
		
		/**
		 * Sets the HTTP method for the request.  This is generally
		 * one of the following: GET, POST, PUT, DELETE
		 * @param emthod the HTTP method
		 */
		public function setMethod( method:String ):void {
			this.method = method;
		}
		
		/**
		 * Sets the optional response processor.  When set, this function will
		 * be invoked after the response to generate the response object passed
		 * to the callback function.  This function should have the signature:
		 * <code>function( request:EsuRestRequest ):Object</code>.
		 * @param responseProcessor the response processing function.
		 */
		public function setResponseProcessor( responseProcessor:Function ):void {
			this.responseProcessor = responseProcessor;
		}
		
		/**
		 * Sets an optional user state object.  This object will
		 * be passed to the callback function in the EsuCallbackState object.
		 * @param state the state object.
		 */
		public function setUserState( state:Object ):void {
			this.userState = state;
		}
		
		/**
		 * Gets the optional user state object.
		 */
		public function getUserState():Object {
			return this.userState;
		}
		
		/**
		 * Sets the EsuApi object that created this request.  This
		 * will be passed to the callback function in the EsuCallbackState
		 * object.
		 * @param esu the EsuApi that created this request.
		 */
		public function setEsuApi( esu:EsuApi ):void {
			this.esu = esu;
		}
		
		/**
		 * Gets the response headers.  This is an object
		 * whose properties are set for each response header. e.g.
		 * <code>getResponseHeaders()["Content-Length"]</code>
		 * @return the response headers
		 */
		public function getReponseHeaders():Object {
			return responseHeaders;
		}
		
		public function getRawHeaders():String {
			return rawHeaders;
		}
		
		/**
		 * Gets the body of the response.  If the response has
		 * no data (like a DELETE request) the byte array will
		 * be zero length.
		 */
		public function getReponseData():ByteArray {
			return responseData;
		}
		
		/**
		 * Sets the shared secret used in the HMAC-SHA1 signature.
		 * @param secret the shared secret
		 */
		public function setSecret( secret:ByteArray ):void {
			this.secret = secret;
		}
		
		/**
		 * Sets the response buffer object used to hold the
		 * response body.
		 */
		public function setResponseBuffer( buffer:ByteArray ):void {
			buffer.position = 0;
			loader.responseData = buffer;
		}
		
		public function setPrivateState( obj:Object ):void {
			this.privateState = obj;
		}
		
		public function getPrivateState():Object {
			return this.privateState;
		}
		
		/**
		 * Sends the request
		 */
		public function send():void {
			// Set the method.
	        loader.method = method;
	        loader.requestData = requestData;
	        loader.uri = uri;

			// Set the Date header
			var date:Date = new Date();
			// converts the Date to UTC by adding or subtracting the time zone offset
			var offsetMilliseconds:Number = date.getTimezoneOffset() * 60 * 1000;
			date.setTime(date.getTime() + offsetMilliseconds);

			// Format it
			var formatter:DateFormatter = new DateFormatter();
			formatter.formatString = DATE_FORMAT;
			
			headers["Date"] = formatter.format( date ) + " UTC";
			headers["x-emc-date"] = formatter.format( date ) + " UTC";
			
			// Sign the request
			signRequest();
			
			loader.requestHeaders = requestHeaders;
			
			loader.addEventListener( HTTPStatusEvent.HTTP_STATUS, onHttpStatus );
			loader.addEventListener( IOErrorEvent.IO_ERROR, onIoError );
			loader.addEventListener( SecurityErrorEvent.SECURITY_ERROR, onSecurityError );
			loader.addEventListener( Event.COMPLETE, onComplete );
			
			// Add this request to the array of running requests.  This ensures that
			// a pointer to this object is kept around so it doesn't get garbage
			// collected while running.  If we don't do this, periodically these
			// objects will 'disappear' in the middle of a request, closing the
			// socket and causing the callback function to never get called.
			runningRequests.push( this );
			loader.load();
		}
		
		/**
		 * Removes the request from the array of running requests
		 * @param req the request to remove.
		 */
		private static function removeRequest( req:EsuRestRequest ):void {
			var index:int = ArrayUtil.getItemIndex( req, runningRequests );
			if( index == -1 ) {
				logger.debug( "Could not remove request: " + req + ".  Not found in runningRequests." );				
			} else {
				runningRequests.splice( index, 1 );
			}
			logger.debug( "Request count " + getRequestCount() );
		}
		
		/**
		 * Returns the number of requests that are currently running.
		 */
		private static function getRequestCount():int {
			return runningRequests.length;
		}
		
		/**
		 * Handles HTTP status events.  We currently don't do anything here.
		 */
		private function onHttpStatus( event:HTTPStatusEvent ):void {
			logger.debug( "onHttpStatus: " + uri );
		}
		
		/**
		 * Handles IO errors.  Fail the request.
		 */
		private function onIoError( event:IOErrorEvent ):void {
			logger.debug( "onIoError: " + uri );
			
			// Notify the callback
			callback( new EsuCallbackState( this.esu, this.method, false, 
				new EsuError( "IOError error during request: " + event.text ), this.userState ) );
				removeRequest( this );
		}
		
		/**
		 * Handles security errors opening the socket.  Fail the request.
		 */
		private function onSecurityError( error:SecurityErrorEvent ):void {
			logger.debug( "onSecurityError: " + uri );
			
			// Sometimes the security error comes after the request completes
			if( complete ) {
				return;
			}
			
			// Notify the callback
			callback( new EsuCallbackState( this.esu, this.method, false, 
				new EsuError( "Security error during request: " + error.text ), this.userState ) );
			removeRequest( this );
		}
		
		/**
		 * Called when the socket is complete.  Check for errors, parse
		 * the response, and notify the callback function.
		 */
		private function onComplete( event:Event ):void {
			logger.debug( "onComplete: " + uri );
			// Copy over headers and data
			responseHeaders = loader.responseHeaders;
			responseData = loader.responseData;
			rawHeaders = loader.rawHeaders;
			
			complete = true;
			
			// Check status code
			if( loader.httpStatus > 299 ) {
				// See if there is a request body.  If so, try to parse
				// an error message out of the XML.
				processError();
				return;
			} else {
				// Success. handle the response.
				var response:Object = null;
				if( responseProcessor != null ) {
					try {
						response = responseProcessor( this );
					} catch( e:EsuError ) {
						// Pass the error to the callback
						callback( new EsuCallbackState( this.esu, this.method, false, e, this.userState ) );
						return;
					} catch( e:Error ) {
						callback( new EsuCallbackState( this.esu, this.method, false, new EsuError( "Error processing response: " + e.message, e ), this.userState ) );
						return;
					}
				}
				
				// Invoke the callback.
				logger.debug( "Success.  Firing callback" );
				callback( new EsuCallbackState( this.esu, this.method, true, null, this.userState, response ) );
			}
			
			removeRequest( this );
		}
		
		/**
		 * Handles error responses.  Attempt to parse an error message and code
		 * out of the body.  If that is not possible, use the HTTP status code
		 * and status message for the error.
		 */
		private function processError():void {
			if( responseData != null && responseHeaders["Content-Length"] != null && Number(responseHeaders["Content-Length"]) > 0 ) {
				responseData.position = 0;
				var responseStr:String = responseData.readUTFBytes( Number(responseHeaders["Content-Length"]) );
				
				try {
					var responseXml:XML = new XML( responseStr );
					
					var message:String = responseXml..Message.toString();
					var code:String = responseXml..Code.toString();
					
					logger.debug( "message: " + message + " code: " + code );
					
					if( message != null && code != null && message.length > 0 && code.length > 0 ) {
						callback( new EsuCallbackState( this.esu, this.method, false, new EsuError( 
							message, null, Number(code) ), this.userState ) );
						removeRequest( this );
						return;
					}
				} catch( e:Error ) {
					logger.debug( "couldn't parse XML response: " + e.message );
				}
			}
			
			// Else, just throw the HTTP status information.
			callback( new EsuCallbackState( this.esu, this.method, false, 
					new EsuError( "Request Failed: " + loader.statusLine, null, loader.httpStatus ), this.userState ) );
            removeRequest( this );

		}
		
		/**
	     * Generates the HMAC-SHA1 signature used to authenticate the request using
	     * the Flex security APIs.
	     */
	    private function signRequest():void {
	        // Build the string to hash.
	        var hashStr:String = "";
	        hashStr += method + "\n";
	
	        // If content type exists, add it.  Otherwise add a blank line.
	        if( headers[ "Content-Type" ] != null ) {
	            logger.debug( "Content-Type: " + headers[ "Content-Type" ] );
	            hashStr += headers[ "Content-Type" ] + "\n";
	        } else {
	            hashStr += "\n";
	        }
	
	        // If the range header exists, add it.  Otherwise add a blank line.
	        if( headers[ "Range" ] != null ) {
	            hashStr += headers[ "Range" ] + "\n";
	        } else {
	            hashStr += "\n";
	        }
	
	        // Add the current date and the resource.
	        hashStr += headers[ "Date" ] + "\n" +
	                resource.toLowerCase();
	        if( uri.query != null && uri.query.length > 0 ) {
	        	hashStr += "?" + uri.query;
	        }
			hashStr += "\n";
	
	        // Do the 'x-emc' headers.  The headers must be hashed in alphabetic
	        // order and the values must be stripped of whitespace and newlines.
	        var keys:Array = new Array(); 
	        var newheaders:Dictionary = new Dictionary();
	
	        // Extract the keys and values
	        for( var key:String in headers ) {
	            if( key.indexOf( "x-emc" ) == 0 ) {
	                keys.push( key.toLowerCase() );
	                newheaders[ key.toLowerCase() ] = normalizeSpace(headers[ key ].replace( /\n/g, "" ));
	            }
	        }
	
	        // Sort the keys and add the headers to the hash string.
	        keys.sort();
	        
	        var first:Boolean = true;
	        for each( var key2:String in keys ) {
	            if( !first ) {
	                hashStr += "\n";
	            } else {
	                first = false;
	            }
	            //this.logger.debug( "xheader: " . k . "." . newheaders[k] );
	            hashStr += key2 + ':' + newheaders[ key2 ];
	        }
	
			var hashOut:String = sign( hashStr, this.secret );
	
	        // Can set all the headers, etc now.
	        requestHeaders = new Array();
	        for( var name:String in headers ) {
	            requestHeaders.push( new URLRequestHeader( name, headers[ name ] ) );
	        }
	
	        // Set the signature header
	        requestHeaders.push( new URLRequestHeader( "x-emc-signature", hashOut ) );
	    }
	    
	    private function normalizeSpace( value:String ):String {
	    	var len:int = value.length;
	    	
	    	while(true) {
	    		value = value.replace( /  /g, " " );
	    		if( value.length == len ) {
	    			return value;
	    		}
	    		len = value.length;
	    	}
	    	
	    	return null;
	    }
	    
	    /**
	     * Signs a string with the given key
	     * 
	     * @param str the string to sign
	     * @param key the key to sign with
	     * @return the base-64 encoded signature value
	     */
	    public static function sign( str:String, key:ByteArray ):String {
	        logger.debug( "Hashing: \n" + str );
	
	        // Compute the signature hash
	        //var hashOut:String = hmacSha1( secret, hashStr );
	        var data:ByteArray = new ByteArray();
	        data.writeUTFBytes( str );
	        var hmacSha1:HMAC = Crypto.getHMAC( "sha1" );
	        var sigBytes:ByteArray = hmacSha1.compute( key, data );
	        
	        // Base-64 encode the signature
	        var encoder:Base64Encoder  = new Base64Encoder;
	        encoder.insertNewLines = false;
	        encoder.encodeBytes( sigBytes, 0, sigBytes.length );
	        var hashOut:String = encoder.toString();
		
	        logger.debug( "Hash: " + hashOut );
	    	return hashOut;
	    }
	}
	    
}